Refactor: extract a shared listSessions pagination helper into base_session_service - #616
Open
AmaadMartin wants to merge 3 commits into
Open
Refactor: extract a shared listSessions pagination helper into base_session_service#616AmaadMartin wants to merge 3 commits into
AmaadMartin wants to merge 3 commits into
Conversation
added 3 commits
August 3, 2026 21:40
The pagination arithmetic behind listSessions was copy-pasted across the in-memory, Vertex AI and database session backends, and had already drifted in shape between them. Collapse it into resolvePagination (pure arithmetic, for backends that paginate in their storage layer) and paginateSessions (sort + slice + wrap, for backends holding the whole result set in memory), both in base_session_service.ts alongside mergeStates. The database backend keeps pushing LIMIT/OFFSET into em.find and only adopts the shared arithmetic; converging it onto paginateSessions would mean loading the whole session table into memory on every call. Behaviour is unchanged, including the quirks: limit === undefined reports limit === totalItems, limit === 0 yields an empty page with a truthful totalItems, page wins over offset, and negative or out-of-range inputs are still passed through unvalidated.
Adds core/test/sessions/base_session_service_test.ts, pinning both helpers directly: the response contract documented on ListSessionsResponse, the limit === 0 and page-beats-offset quirks, ordering with its id tie-break, and that paginateSessions leaves its input array untouched. Also adds a DatabaseSessionService case for offset without limit. That query branch was never exercised, so nothing pinned the metadata the branch now gets from resolvePagination.
- Drop ResolvedPagination.take. It was always exactly request.limit, and no storage backend read it: DatabaseSessionService passes its own destructured limit to em.find. Every caller already holds the request, so paginateSessions reads limit from there. - Fold the take === undefined ternary into slice's optional end argument; slice(start, undefined) already means "to the end". - Replace the two sign-mirrored comparators and their selection expression with one comparator scaled by a direction factor. -0 is falsy, so the id tie-break still applies to equal timestamps in both directions. - Merge the two limit-less database branches, which now differ only in how totalItems is obtained. The no-count fast path is preserved: the rows are the whole result set unless an offset skipped some.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A — no public issue tracks this refactor.
Problem: The pagination arithmetic behind
listSessionsis copy-pasted across every session backend.InMemorySessionService,VertexAiSessionServiceandDatabaseSessionServiceeach independently computeeffectiveOffset,effectivePageandtotalPages, and each re-implements thelimit === 0andlimit === undefinedspecial cases. The three copies have already drifted in shape — the in-memory copy carries a whole duplicate branch for the "app or user not found" case, Vertex has no such branch, and the database copy spells the same arithmetic a third way. Every new backend has to re-derive it, and every fix has to be applied three times.Solution: Collapse the arithmetic into two exported helpers in
core/src/sessions/base_session_service.ts, alongside the existingmergeStates/trimTempState:resolvePagination(request, totalItems)— pure arithmetic. Turns aListSessionsRequestplus a known total into the sliceoffsetand the response metadata. For backends that paginate in their storage layer, where theoffsetand the request's ownlimitmap ontoOFFSET/LIMIT.paginateSessions(sessions, request)— sorts, slices and wraps, built onresolvePagination. For backends that hold the whole result set in memory.Then converge the three landed backends onto them. Net effect on
core/src/sessions/: −188 lines of duplicated arithmetic, +137 lines of single implementation, for a net −51.Design notes:
resolvePaginationfor the numbers only, and still pusheslimit/offsetdown intoem.find. Converging it ontopaginateSessionswould read as tidier but would meanem.find-ing the entire session table into Node memory on everylistSessionscall — an unbounded-memory regression on the one backend actually backed by a database. The arithmetic is what is shared; the query strategy is a storage-layer concern.{page: 1, limit: 0, totalItems: 0, totalPages: 0}becauselimitis reported astotalItems; with a limit,totalPagesislimit === 0 ? 0 : Math.ceil(0 / limit), which is0either way, and[].slice(x, y)is[]. Three new tests pin the deleted branch's outputs directly (empty input with no params, with a limit, and with a limit + page).paginateSessionsdoes not mutate its input. It sorts a shallow copy, and only whenorderis set. The current call sites all pass freshly built local arrays so this is not observable today, but a shared helper that silently reorders a caller's array is a trap. Pinned by a test.limit === undefinedreportslimit: totalItems;limit: 0returns an empty page but a truthfultotalItems;pagebeatsoffset;totalPagesis0(not1) for an empty result set; negative and out-of-range values are still passed through unvalidated (page: 0still yields a negative slice start). Adding validation would be a behaviour change and belongs in its own PR.core/src/index.ts. These are internal cross-module helpers consumed by sibling files via relative imports, exactly likemergeStates. Public signatures of the threelistSessionsmethods are byte-identical before and after.Convergence acceptance check —
core/src/sessions/now holds exactly one copy of the arithmetic:Collision check (required before starting; recorded here either way). Searched all 514 open PRs on the fork:
No open PR extracts or shares the pagination arithmetic — zero hits for "paginat" across every open title and branch name. Three open PRs touch the same files with a different concern and were checked by diff: #376 (makes
ListSessionsRequest.userIdoptional), #477 (aligns thelistSessionsstate contract, stacked on #376) and #609 (extracts a sharedextractStateDeltahelper). None of them touches the pagination arithmetic, so this branches frommainrather than stacking. #376 does independently delete the same in-memory "app/user not found" branch, so whichever of the two lands second needs a one-hunk rebase inin_memory_session_service.ts.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
New file
core/test/sessions/base_session_service_test.ts(24 cases) covers both helpers directly: the documented no-params contract, empty input with and without a limit and page (the deleted in-memory branch),limitonly,offsetonly,limit+offset,page+limit,pagebeatingoffset,limit: 0, an offset beyond the total, both sort directions, theid.localeComparetie-break in both directions, order-omitted passthrough, and non-mutation of the input array.resolvePaginationis additionally exercised on its own — that is the surface the database backend uses, and it must be covered without going throughpaginateSessions.One case was added to
core/test/sessions/database_session_service_test.ts:offsetwithoutlimit. That query branch had no test at all, so nothing pinned the metadata it now gets fromresolvePagination.No existing test was modified. The ~10 pagination cases in
in_memory_session_service_test.ts, thelistSessions pagination and sortingblock indatabase_session_service_test.tsand the Vertex cases invertex_ai_session_service_test.tsare the regression net that proves the refactor is behaviour-preserving; all 158 tests incore/test/sessions/pass unmodified.Coverage. 100% of the new code in
base_session_service.ts(statements, branches, functions) and of all three rewrittenlistSessionsbodies, measured with@vitest/coverage-v8overcore/test/sessions/. The only uncovered lines remaining inbase_session_service.tsare pre-existing (getOrCreateSession,appendEvent,updateSessionState), untouched by this change.Proof the tests can fail. Coverage is a floor, not proof, so each mutation below was applied to the helper one at a time and sequentially, the suite re-run, and the mutation reverted. After the last revert,
git diff HEADwas empty — no mutation survived.totalPages: limit === 0 ? 0 : Math.ceil(...)→Math.ceil(...)returns no sessions but a truthful total for limit 0expected { sessions: [], page: 1, …(3) } to deeply equal { … }(totalPages: Infinityvs0)(page - 1) * limit→page * limitslices by page number when page and limit are givenexpected [] to deeply equal [ 's3', 's4' ](+ 7 more, incl. the in-memory, database and Vertexpage + limitcases)totalPages: totalItems === 0 ? 0 : 1→totalPages: 1reports an empty result set with no pagination paramsexpected { sessions: [], page: 1, …(3) } to deeply equal { … }(totalPages: 1vs0)Math.floor(offset / limit) + 1→Math.floor(offset / limit)derives the page number from limit and offsetexpected 1 to be 2(+ 5 more)|| a.id.localeCompare(b.id)from both comparatorsbreaks ascending ties by id/breaks descending ties by idexpected [ 'c', 'a', 'b' ] to deeply equal [ 'a', 'b', 'c' ]offset: request.offset ?? 0→offset: 0in the no-limit pathskips offset sessions and reports the pre-offset total as the limitexpected [ 's1', 's2', 's3', 's4' ] to deeply equal [ 's4' ][...sessions].sort(...)→sessions.sort(...)does not mutate the input arrayexpected [ 'b', 'c', 'a' ] to deeply equal [ 'b', 'a', 'c' ]Manual End-to-End (E2E) Tests:
No integration test is added: this refactor performs no I/O and crosses no process boundary. The
DatabaseSessionServicesuite already runs end to end against a real in-memory SQLite database (SqliteDriver,dbName: ':memory:'), so it is the integration-level proof thatresolvePagination's numbers still produce correct SQLLIMIT/OFFSET.To reproduce locally:
npm run ts:checkreports 281 errors, but that is the pre-existing state of the test tree onmain(unresolvable@google/adk/...subpath imports anddistvssrctype identity — the subject of separate open PRs). The count is 281 before and 281 after this change, and none of them are in the files this PR touches.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.
Review round 1 — complexity
A simplicity audit raised four non-blocking findings; all four are implemented in commit
Refactor: shrink the pagination helpers per review:ResolvedPagination.takedeleted. It was always exactlyrequest.limit, and no storage backend read it —DatabaseSessionServicepasses its own destructuredlimittoem.find. Every caller already holds therequest, sopaginateSessionsnow readslimitfrom there. This is a deliberate departure from the approved spec's data model, which listedtake?: number: the field had no reader outside the module that produced it.ResolvedPaginationis now{offset, meta}.take === undefinedternary folded intoslice's optionalend.slice(start, undefined)already means "to the end";limit: 0still yields[]viaslice(offset, offset).-0is falsy, so theid.localeComparetie-break still applies to equal timestamps in both directions — pinned by mutations 5 and 8.totalItemsis obtained. The no-COUNTfast path is preserved and is pinned by mutation 10.Four
resolvePaginationassertions in the new test file dropped theirtakeexpectation, since the field no longer exists. No pre-existing test was touched:git diff main -- core/test/contains zero deleted lines.CI
All test jobs pass on the current head:
run-tests(ubuntu-latest, macos-latest, windows-latest) plus the aggregaterun-testsjob.Two known intermittent timeouts on the non-Linux runners needed a re-run and are unrelated to this change — it touches only
core/src/sessions/**andcore/test/sessions/**, and each failing run was 1 failure out of ~2700 tests:tests/integration/app_loader/app_loader_test.ts > should discover apps vs agents across directories and standalone files,Test timed out in 40000ms(macos-latest).core/test/code_executors/unsafe_local_code_executor_test.ts > should execute shell code and return stdout,Test timed out in 5000ms(windows-latest).ubuntu-latest and the aggregate
run-testsjob passed on every attempt.